<?php
/* This file contains helper functions for using the lasso library. */

/* Load the configuration. */
require_once('config.php');

/* Load the lasso library if it isn't loaded. */
if (!extension_loaded('lasso')) {
  $prefix = (PHP_SHLIB_SUFFIX == 'dll') ? 'php_' : '';
  dl($prefix . 'lasso.' . PHP_SHLIB_SUFFIX);
}

$res = lasso_init();
if($res != 0) {
  die('Failed to initialize lasso library. Return code: ' . $res);
}

/* Initialize lasso server for this SP. */
$lp_server = new LassoServer($lp_sp_metadata_file);
if(!$lp_server) {
  die('Failed to initialize lasso server object.');
}

/* Add the IdP's configuration to the lasso server. */
$res = $lp_server->addProvider(LASSO_PROVIDER_ROLE_IDP,
                               $lp_idp_metadata_file, $lp_idp_public_key_file);
if($res != 0) {
  die('Failed to load the IdP\'s configuration into lasso. IdP' .
      ' metadata file: ' . $lp_idp_metadata_file . ' IdP public key' .
      ' file: ' . $lp_idp_public_key_file);
}


/* Helper class for XML parsing.
 *
 * We are limited in the number of XML APIs we can use since this script should
 * work on PHP 4. This class uses a xml parser which creates a stream of XML
 * elements.
 *
 * With the help of a stack where the current element is always on the top,
 * we are able to construct a tree based on arrays of arrays. This tree can be
 * retrieved from the $elements variable.
 *
 * Example:
 *  XML:
 *   <saml:Attribute xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Name="mail">
 *     <saml:AttributeValue>ZXhhbXBsZUBleGFtcGxlLmNvbQ==</saml:AttributeValue>
 *   </saml:Attribute>
 *
 *  PHP array:
 *   Array(
 *     0 => Array(
 *       'name' => 'urn:oasis:names:tc:SAML:2.0:assertion:Attribute',
 *       'attribs' => Array(
 *         'Name' => 'mail'
 *       ),
 *       'children' => Array(
 *         0 => "\n  ",
 *         1 => Array(
 *           'name' => 'urn:oasis:names:tc:SAML:2.0:assertion:AttributeValue',
 *           'attribs' => Array(),
 *           'children' => Array(
 *             0 => 'ZXhhbXBsZUBleGFtcGxlLmNvbQ=='
 *           )
 *         ),
 *         2 => "\n"
 *       )
 *     )
 *   )
 */
class lp_xml_parser
{
  var $parser;

  /* Array of all top-level elements in this xml document. */
  var $elements;

  /* Stack where the top is the current element list we reading. */
  var $stack;

  /* Initializes an XML parser object, and parses the given xml string.
   *
   * Parameters:
   *  $xml          The XML string we should parse.
   */
  function lp_xml_parser($xml)
  {
    $this->elements = array();
    /* Initialize the stack. */
    $this->stack = array(&$this->elements);


    $this->parser = xml_parser_create_ns();


    /* Configure the parser. */
    if(!xml_parser_set_option($this->parser, XML_OPTION_CASE_FOLDING, 0)) {
      die('Error setting XML_OPTION_CASE_FOLDING option in parser to 0.');
    }

    if(!xml_set_object($this->parser, $this)) {
      die('Error setting object for xml parsing.');
    }

    if(!xml_set_element_handler($this->parser, "start_tag", "end_tag")) {
      die('Error setting element handlers for xml parsing.');
    }

    if(!xml_set_character_data_handler($this->parser, "cdata")) {
      die('Error setting character data handler for xml parsing.');
    }

    /* Parse the XML string. */
    if(!xml_parse($this->parser, $xml, TRUE)) {
      die('Error parsing xml.');
    }

    /* Free the parser. */
    if(!xml_parser_free($this->parser)) {
      die('Error freeing xml parser.');
    }

    /* Merge all text nodes. */
    $this->elements = $this->merge_text_nodes($this->elements);
  }


  /* This function add an element to the elemets array which is on the top of
   * the stack.
   *
   * Parameters:
   *  $element      This is the element which should be added. This may be a
   *                normal XML element, or a text node.
   */
  function add_child($element)
  {
    /* Find the index of the top element on the stack. */
    $i = count($this->stack) - 1;

    /* Add this node to the child nodes list at the top of the stack. */
    array_push($this->stack[$i], $element);
  }

  /* This function handles starts of XML elements.
   * Please refer to the following information for more information:
   * http://www.php.net/manual/en/function.xml-set-element-handler.php
   */
  function start_tag($parser, $name, $attribs)
  {
    /* Create a new node with the given name and attributes. */
    $node = array();
    $node['name'] = $name;
    $node['attribs'] = $attribs;

    /* Create an array for child nodes and add it to the node. */
    $c = array();
    $node['children'] = &$c;

    /* Add this node as a child node of the current node. */
    $this->add_child($node);

    /* Add this node to the top of the stack. */
    array_push($this->stack, &$c);
  }

  /* This function handles ends of XML elements.
   * See: http://www.php.net/manual/en/function.xml-set-element-handler.php
   */
  function end_tag($parser, $name)
  {
    /* Pop this node from the stack. */
    array_pop($this->stack);
  }

  /* This function handles text nodes.
   * Please refer to the following URL for more information:
   * http://www.php.net/manual/en/function.xml-set-character-data-handler.php
   */
  function cdata($parser, $data)
  {
    /* Add this string as a child element of the current node. */
    $this->add_child($data);
  }

  /* This function merges text nodes which are next to each other into a
   * single text node.
   *
   * Parameters:
   *  $elements     Array of nodes.
   *
   * Returns:
   *  A new array with the text nodes in this array and all child nodes merged
   *  together.
   */
  function merge_text_nodes($elements) {
    $ret = array();
    $current_text = NULL;

    foreach($elements as $k => $v) {
      if(is_string($v)) {
        if(isset($current_text)) {
          $current_text .= $v;
        } else {
          $current_text = $v;
        }
      } else {
        if(isset($current_text)) {
          array_push($ret, $current_text);
          $current_text = NULL;
        }

        $v['children'] = $this->merge_text_nodes($v['children']);
        array_push($ret, $v);
      }
    }

    if(isset($current_text)) {
      array_push($ret, $current_text);
    }

    return $ret;

  }


  /* This function removes text nodes which are only whitespace from the
   * list of nodes in $elements and all child nodes.
   *
   * Parameters:
   *  $elements     Array of nodes we should remove whitespace nodes from.
   *
   * Returns:
   *  A new array with all whitespace nodes removed.
   */
  function remove_whitespace_array($elements) {
    $ret = array();

    foreach($elements as $k => $v) {
      if(is_string($v)) {
        if(trim($v) != '') {
          array_push($ret, $v);
        }
      } else {
        $v['children'] = $this->remove_whitespace_array($v['children']);
        array_push($ret, $v);
      }
    }

    return $ret;
  }

  /* This function removed all whitespace nodes from this XML object. */
  function remove_whitespace()
  {
    $this->elements = $this->remove_whitespace_array($this->elements);
  }
}


/* Function to do a POST request to the specified url. This function will set
 * the Content-Type of the request to text/xml.
 *
 * Parameters:
 *  $url            The absolute URL we should POST to.
 *  $data           Encoded POST data.
 *
 * Returns:
 *  The data we receive in response.
 */
function lp_post_data($url, $data)
{
  global $lp_ca_path;

  $curl = curl_init($url);
  if($curl == FALSE) {
    die('Error initializing curl handle.');
  }

  if(!curl_setopt($curl, CURLOPT_POST, TRUE)) {
    die('Error setting request method to POST in curl request.');
  }

  if(!curl_setopt($curl, CURLOPT_POSTFIELDS, $data)) {
    die('Error setting POST data in curl request.');
  }

  $headers = array('Content-Type: text/xml');
  if(!curl_setopt($curl, CURLOPT_HTTPHEADER, $headers)) {
    die('Error setting Content-Type header in curl request.');
  }

  if(!curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE)) {
    die('Error enabling return-transfer curl option.');
  }

  if(!curl_setopt($curl, CURLOPT_BINARYTRANSFER, TRUE)) {
    die('Error enabling binary transfer curl option.');
  }

  if(!curl_setopt($curl, CURLOPT_FAILONERROR, TRUE)) {
    die('Error enabling fail on error curl option.');
  }


  if(isset($lp_ca_path)) {
    if(!curl_setopt($curl, CURLOPT_CAPATH, $lp_ca_path)) {
      die('Error setting curl ca path to: ' . $lp_ca_path);
    }
  }


  $response = curl_exec($curl);
  if($response == FALSE) {
    die('Error executing curl request. Curl error: [' . curl_errno($curl) .
        '] ' . curl_error($curl));
  }

  curl_close($curl);

  return $response;
}


/* Function to initialize and send a login request to the IdP. This function
 * will redirect the user to the IdP.
 *
 * Parameters:
 *  $return_to      The absolute URL to the page the user should be returned
 *                  to after a successful login.
 */
function lp_init_login($return_to)
{
  /* Save the $return_to parameter in the session. */
  lp_session_set('LP_RETURN_TO', $return_to);

  global $lp_server;
  global $lp_idp_entityid;

  $login = new LassoLogin($lp_server);
  if(!$login) {
    die('Error creating LassoLogin object.');
  }

  $res = $login->initAuthnRequest($lp_idp_entityid, LASSO_HTTP_METHOD_REDIRECT);
  if($res != 0) {
    die('Failed to initialize authn request. Lasso error code: ' . $res);
  }

  $request = $login->request;
  $request->ForceAuthn = 0;
  $request->IsPassive = 0;


  $res = $login->buildAuthnRequestMsg();
  if($res != 0) {
    die('Failed to serialize authn request. Lasso error code: ' . $res);
  }

  header('Location: ' . $login->msgUrl);
}

/* Helper function to convert a NodeList object to an array for use in a
 * foreach-statement.
 *
 * Parameters:
 *  $node_list      A lasso object of type NodeList.
 *
 * Returns:
 *  An array of the nodes in the NodeList object.
 */
function lp_node_list_to_array($node_list)
{
  $result = array();

  for($i = 0; $i < $node_list->length(); $i++) {
    $result[$i] = $node_list->getItem($i);
  }

  return $result;
}


/* Helper function to extract the attributes we receive from the IdP.
 *
 * Parameters:
 *  $login          A LassoLogin object which contains the response we got
 *                  to our login request.
 *
 * Returns:
 *  An array of the attributes we received in the response.
 */
function lp_extract_assertions($login)
{
  $result = array();

  $response = $login->response;
  $nodes = lp_node_list_to_array($response->Assertion);

  /* The lasso library currently only validates the first assertion element
   * it finds. Therefore we die if we find more than one assertion.
   */
  if(count($nodes) > 1) {
    die('More than one Assertion element, but Lasso only validates the first.');
  }

  /* Decode the assertions. */
  foreach($nodes as $assertion) {
    lp_handle_assertion($assertion, $result);
  }

  return $result;
}


/* Helper function which finds attribute values in an Assertion element. */
function lp_handle_assertion($assertion, &$result)
{
  $nodes = lp_node_list_to_array($assertion->AttributeStatement);

  foreach($nodes as $attribute_statement) {
    lp_handle_attribute_statement($attribute_statement, $result);
  }
}


/* Helper function which finds attribute values in an AttributeStatement
 * element.
 */
function lp_handle_attribute_statement($attribute_statement, &$result)
{
  $nodes = lp_node_list_to_array($attribute_statement->Attribute);

  foreach($nodes as $attribute) {
    lp_handle_attribute($attribute, $result);
  }
}

/* Helper function which finds attribute values in an Attribute element. */
function lp_handle_attribute($attribute, &$result)
{
  $name = $attribute->Name;

  /* Extract the xml dump of the Saml2Attribute node, and parse this xml. */
  $xml = $attribute->dump();
  $xml_parser = new lp_xml_parser($xml);
  $xml_parser->remove_whitespace();

  /* We assume that the XML contains one Attribute element which contains
   * AttributeValue nodes.
   */
  $attr = $xml_parser->elements[0];
  if(!$attr
     || $attr['name'] != 'urn:oasis:names:tc:SAML:2.0:assertion:Attribute') {
    die('An Attribute element wasn\'t the first element in a dump of an' .
	' Attribute element.');
  }

  /* Create the array for the attribute values if it doesn't exist yet. */
  if(!array_key_exists($name, $result)) {
    $result[$name] = array();
  }

  foreach($attr['children'] as $attrvalue) {
    if($attrvalue['name']
       != 'urn:oasis:names:tc:SAML:2.0:assertion:AttributeValue') {
      die('An Attribute element contained a child element which wasn\'t an' .
	  ' AttributeValue element.');
    }

    /* We assume that there is one text node in the AttributeValue element. */
    $values = $attrvalue['children'][0];

    /* Decode the value(s). */
    $values = lp_decode_attribute($name, $values);

    /* Skip this AttributeValue if the decoder returns NULL. */
    if($values == NULL) {
      next;
    }

    if(is_array($values)) {
      /* If this is an array, then it consists of multiple values. */
      foreach($values as $value) {
	array_push($result[$name], $value);
      }
    } else {
      /* it is a single value which should be stored. */
      array_push($result[$name], $values);
    }
  }
}


/* Function to handle the response to the login request.
 *
 * After successfully processing a login response, it will redirect the user to
 * the url which was set in the lp_init_login function.
 */
function lp_handle_login_response()
{
  global $lp_server;

  $login = new LassoLogin($lp_server);
  if(!$login) {
    die('Error creating LassoLogin object.');
  }

  if($_SERVER['REQUEST_METHOD'] == 'POST') {
    /* Handle HTTP-POST binding. */

    if(!array_key_exists('SAMLResponse', $_POST)) {
      die('No SAML reply included in login response.');
    }

    $SAMLResponse = base64_decode($_POST['SAMLResponse']);

    $res = $login->processAuthnResponseMsg($SAMLResponse);
    if($res != 0) {
      die('Failed to process SAML2 response message. Lasso error code: ' . $res);
    }

  } else if($_SERVER['REQUEST_METHOD'] == 'GET' || $_SERVER['REQUEST_METHOD'] == 'HEAD') {
    /* Handle HTTP-Artifact binding. */

    if(!array_key_exists('SAMLart', $_GET)) {
      die('No SAML artifact id included in login response.');
    }

    $res = $login->initRequest($_SERVER['QUERY_STRING'],
                               LASSO_HTTP_METHOD_ARTIFACT_GET);
    if($res != 0) {
      die('Error handling artifact response. Lasso error code: ' . $res);
    }

    $res = $login->buildRequestMsg();
    if($res != 0) {
      die('Error building artifact download request. Lasso error code: ' . $res);
    }

    $url = $login->msgUrl;
    $data = $login->msgBody;

    $request_info = array();

    $response = lp_post_data($url, $data);

    $res = $login->processResponseMsg($response);
    if($res != 0) {
      die('Failed to process artifact response. Lasso error code: ' . $res);
    }

  } else {
    die('Invalid request method for login response handler.');
  }

  /* Accept the login.*/
  $login->acceptSso();

  /* Save the identity of the user for use during logout. */
  $identity = $login->identity;
  if(isset($identity)) {
    lp_session_set('LP_IDENTITY', $identity->dump());
  }
  $session = $login->session;
  if(isset($session)) {
    lp_session_set('LP_SESSION', $session->dump());
  }


  /* Save the attributes of the session. */
  $attributes = lp_extract_assertions($login);
  lp_on_login($attributes);

  /* Redirect the user back to the page which was set in the $return_to
   * parameter to lp_init_login.
   */
  $return_to = lp_session_get('LP_RETURN_TO');
  if(!isset($return_to)) {
    die('Unable to retrieve redirect URL from session. ' .
        'Perhaps cookies are disabled?');
  }

  /* We use the "303 See Other" status code to indicate that the POST data
   * shouldn't be included in the redirect. */
  header("HTTP/1.1 303 See Other");
  header('Location: ' . $return_to);
}


/* This function is called from lp_handle_logout when we receive a logout
 * request from the IdP. We receive a logout request when the user initiates
 * a global logout from any other site than our own.
 */
function lp_handle_logout_request()
{
  global $lp_server;

  $logout = new LassoLogout($lp_server);
  if(!$logout) {
    die('Error creating LassoLogout object.');
  }

  $res = $logout->processRequestMsg($_SERVER['QUERY_STRING']);
  if($res < 0) {
    die('Failed to process logout request message. Lasso error code: ' . $res);
  }

  /* We don't bother with any validation of the request. If someone wishes to
   * force a user to log out, they may as well redirect the user to a url which
   * initializes a logout.
   */

  /* Create response message. */
  $res = $logout->buildResponseMsg();
  if($res != 0) {
    die('Error building logout response message. Lasso error code: ' . $res);
  }

  /* Log the user out. */
  lp_on_logout();

  /* Redirect the user back to the IdP. */
  header('Location: ' . $logout->msgUrl);
}


/* This function handles logout responses from the IdP. We receive a response
 * after we send a logout request to the IdP.
 * In this function we will only redirect the user to the URL which was passed
 * to the lp_init_logout function. The real logout was done before we
 * redirected the user to the IdP.
 */
function lp_handle_logout_response()
{
  global $lp_server;

  $logout = new LassoLogout($lp_server);
  if(!$logout) {
    die('Error creating LassoLogout object.');
  }

  $res = $logout->processResponseMsg($_SERVER['QUERY_STRING']);
  if($res != 0) {
    die('Failed to process logout response message. Lasso Error code: ' .
        $res);
  }

  /* Since the session is destroyed, we have to retrieve the url the user
   * should be redirected to from the RelayState-
   */
  $redirect_to = $_GET['RelayState'];
  if(empty($redirect_to)) {
    die('Missing RelayState in logout response.');
  }

  /* Redirect the user to the URL. */
  header('Location: ' . $redirect_to);
}


/* This functions handles both logout requests (when the IdP is requesting that
 * we log out, and logout responses (when we send a logout request to the IdP).
 * Currently we only support the HTTP-Redirect binding for both requests and
 * responses.
 *
 * After processing a logout response, we will redirect the user to the url
 * set in the $return_to parameter to lp_logout_init.
 */
function lp_handle_logout()
{
  if(array_key_exists('SAMLRequest', $_GET)) {
    lp_handle_logout_request();
  } elseif(array_key_exists('SAMLResponse', $_GET)) {
    lp_handle_logout_response();
  } else {
    die('Invalid request to logout handler.');
  }
 
}


/* This function initializes a logout request and sends it to the IdP.
 *
 * Parameters:
 *  $redirect_to    Absolute URL the user will be redirected to after
 *                  logging out.
 */
function lp_init_logout($redirect_to)
{
  $identityDump = lp_session_get('LP_IDENTITY');
  $sessionDump = lp_session_get('LP_SESSION');

  if(!isset($sessionDump)) {
    /* We aren't currently logged in. Redirect the user immediately. */
    header('Location: ' . $redirect_to);
    return;
  }

  /* Destroy the session. This way the user will at least be able to log out of
   * this service if the logout fails at the IdP.
   */
  lp_on_logout();

  global $lp_server;
  global $lp_idp_entityid;

  $logout = new LassoLogout($lp_server);
  if(!$logout) {
    die('Error creating LassoLogout object.');
  }

  if(isset($sessionDump)) {
    $res = $logout->setSessionFromDump($sessionDump);
    if($res != 0) {
      die('Failed to restore user session from dump.');
    }
  }
  if(isset($identityDump)) {
    $res = $logout->setIdentityFromDump($identityDump);
    if($res != 0) {
      die('Failed to restore user identity from dump.');
    }
  }

  $res = $logout->initRequest($lp_idp_entityid, LASSO_HTTP_METHOD_REDIRECT);
  if($res != 0) {
    die('Failed to initialize logout request. Lasso error code: ' . $res);
  }

  $request = $logout->request;

  $session = $logout->session;
  $assertions = $session->getAssertions($lp_idp_entityid);


  /* Add the SessionIndex parameter to the logout request. */

  /* Currently, we only look at the first AuthnStatement in the first Assertion
   * element. */
  $assertion = $assertions->getItem(0);
  $authnStatements = $assertion->AuthnStatement;
  $authnStatement = $authnStatements->getItem(0);
  $sessionIndex = $authnStatement->SessionIndex;

  if($sessionIndex) {
    $request->SessionIndex = $sessionIndex;
  }

  /* Set the RelayState in the hope that it will be added to the query
   * string some day.
   */
  $request->RelayState = $redirect_to;

  $res = $logout->buildRequestMsg();
  if($res != 0) {
    die('Error building logout request message. Lasso error code: ' . $res);
  }

  $url = $logout->msgUrl;

  /* Add the RelayState parameter if Lasso doesn't add it. */
  if(strpos($url, '&RelayState=') == FALSE &&
     strpos($url, '?RelayState=') == FALSE) {
    $url .= '&RelayState=' . urlencode($redirect_to);
  }

  /* Redirect to the IdP. */
  header('Location: ' . $url);
}

?>